1use crate::registry::{
18 DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA,
19};
20
21use super::agents::AgentDefinition;
22use super::content::AgentContent;
23use super::skills::AgentSkill;
24use super::store::Store;
25
26pub(super) fn agent_definition_to_record(
30 def: &AgentDefinition,
31 content: &[AgentContent],
32 skills: &[AgentSkill],
33) -> DefinitionRecord {
34 let data = DefinitionRecordV1 {
35 id: def.id.clone(),
36 slug: def.slug.clone(),
37 name: def.name.clone(),
38 icon: def.icon.clone(),
39 provider: def.provider.clone(),
40 description: def.description.clone(),
41 working_directory: def.working_directory.clone(),
42 shell: def.shell.clone(),
43 provider_flags: def.provider_flags.clone(),
44 auto_start: def.auto_start,
45 restart_on_crash: def.restart_on_crash,
46 idle_timeout_minutes: def.idle_timeout_minutes,
47 created_at: def.created_at,
48 agent_type: def.agent_type.clone(),
49 environment: def.environment.clone(),
50 agent_bus_id: def.agent_bus_id.clone(),
51 is_seeded: def.is_seeded,
52 accounts: def.accounts.clone(),
53 parent_id: def.parent_id.clone(),
54 branch_label: def.branch_label.clone(),
55 updated_at: def.updated_at,
56 user_hidden: def.user_hidden,
57 container_image: def.container_image.clone(),
58 container_volumes: def.container_volumes.clone(),
59 container_name: def.container_name.clone(),
60 content: content
61 .iter()
62 .map(|c| DefContentBlob {
63 content_type: c.content_type.clone(),
64 content: c.content.clone(),
65 })
66 .collect(),
67 skills: skills
68 .iter()
69 .map(|s| DefSkillBlob {
70 id: s.id.clone(),
71 name: s.name.clone(),
72 trigger: s.trigger.clone(),
73 skill_type: s.skill_type.clone(),
74 description: s.description.clone(),
75 content: s.content.clone(),
76 })
77 .collect(),
78 };
79 DefinitionRecord {
80 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
81 data,
82 }
83}
84
85pub(super) fn record_to_agent_definition(rec: &DefinitionRecord) -> AgentDefinition {
91 let d = &rec.data;
92 AgentDefinition {
93 id: d.id.clone(),
94 slug: d.slug.clone(),
95 name: d.name.clone(),
96 icon: d.icon.clone(),
97 provider: d.provider.clone(),
98 description: d.description.clone(),
99 working_directory: d.working_directory.clone(),
100 shell: d.shell.clone(),
101 provider_flags: d.provider_flags.clone(),
102 auto_start: d.auto_start,
103 restart_on_crash: d.restart_on_crash,
104 idle_timeout_minutes: d.idle_timeout_minutes,
105 created_at: d.created_at,
106 agent_type: d.agent_type.clone(),
107 environment: d.environment.clone(),
108 agent_bus_id: d.agent_bus_id.clone(),
109 is_seeded: d.is_seeded,
110 accounts: d.accounts.clone(),
111 parent_id: d.parent_id.clone(),
112 branch_label: d.branch_label.clone(),
113 updated_at: d.updated_at,
114 user_hidden: d.user_hidden,
115 container_image: d.container_image.clone(),
116 container_volumes: d.container_volumes.clone(),
117 container_name: d.container_name.clone(),
118 }
119}
120
121impl Store {
122 pub(super) fn registry_def_upsert(&self, def_id: &str) {
130 let Some(reg) = self.shared_def_registry() else {
131 return;
132 };
133 let def = match self.agent_def_get(def_id) {
134 Ok(Some(d)) => d,
135 Ok(None) => return,
136 Err(e) => {
137 tracing::warn!(def_id, error = %e, "def registry: read definition failed, skipping mirror");
138 return;
139 }
140 };
141 if def.is_seeded != 0 {
145 return;
146 }
147 let content = self.agent_content_get_all_local(def_id).unwrap_or_default();
151 let skills = self.agent_skill_list_local(def_id).unwrap_or_default();
152 let rec = agent_definition_to_record(&def, &content, &skills);
153 if let Err(e) = reg.upsert(&rec) {
154 tracing::warn!(def_id, error = %e, "def registry: mirror upsert failed");
155 }
156 }
157
158 pub(super) fn registry_def_retire(&self, def_id: &str) -> bool {
162 let Some(reg) = self.shared_def_registry() else {
163 return false;
164 };
165 let existed = reg.exists(def_id);
169 if let Err(e) = reg.retire(def_id) {
170 tracing::warn!(def_id, error = %e, "def registry: mirror retire failed");
171 return false;
172 }
173 existed
174 }
175
176 pub(super) fn registry_def_update_definition_fields(&self, agent: &AgentDefinition) -> bool {
182 let Some(reg) = self.shared_def_registry() else {
183 return false;
184 };
185 let mut rec = match reg.get(&agent.id) {
186 Ok(Some(r)) => r,
187 _ => return false,
188 };
189 let kept_content = std::mem::take(&mut rec.data.content);
191 let kept_skills = std::mem::take(&mut rec.data.skills);
192 let mut new_rec = agent_definition_to_record(agent, &[], &[]);
193 new_rec.data.content = kept_content;
194 new_rec.data.skills = kept_skills;
195 if let Err(e) = reg.upsert(&new_rec) {
196 tracing::warn!(def_id = %agent.id, error = %e, "def registry: cross-channel update failed");
197 return false;
198 }
199 true
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::registry::{
207 DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DefinitionStore,
208 DEF_MAX_SUPPORTED_SCHEMA,
209 };
210 use std::sync::Arc;
211
212 fn global_user_agent(id: &str, name: &str) -> DefinitionRecord {
213 DefinitionRecord {
214 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
215 data: DefinitionRecordV1 {
216 id: id.to_string(),
217 name: name.to_string(),
218 provider: "claude".to_string(),
219 is_seeded: 0,
220 content: vec![DefContentBlob {
221 content_type: "agentmd".to_string(),
222 content: "be helpful".to_string(),
223 }],
224 skills: vec![DefSkillBlob {
225 id: "sk1".to_string(),
226 name: "greet".to_string(),
227 ..Default::default()
228 }],
229 ..Default::default()
230 },
231 }
232 }
233
234 #[test]
235 fn read_first_surfaces_cross_channel_agent_with_content_and_skills() {
236 let store = Store::open_in_memory().unwrap();
237 let tmp = tempfile::tempdir().unwrap();
238 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
239 def_store
241 .upsert(&global_user_agent("remote-1", "Remote"))
242 .unwrap();
243 store.set_def_registry(def_store);
244
245 let list = store.agent_def_list().unwrap();
247 assert!(
248 list.iter().any(|d| d.id == "remote-1" && d.name == "Remote"),
249 "agent_def_list must include the global-only user agent"
250 );
251
252 let content = store.agent_content_get_all("remote-1").unwrap();
255 assert_eq!(content.len(), 1);
256 assert_eq!(content[0].content, "be helpful");
257 let skills = store.agent_skill_list("remote-1").unwrap();
258 assert_eq!(skills.len(), 1);
259 assert_eq!(skills[0].name, "greet");
260 }
261
262 fn local_def(id: &str, name: &str) -> AgentDefinition {
263 AgentDefinition {
264 id: id.to_string(),
265 slug: id.to_string(),
266 name: name.to_string(),
267 icon: "✦".to_string(),
268 provider: "claude".to_string(),
269 description: String::new(),
270 working_directory: String::new(),
271 shell: String::new(),
272 provider_flags: String::new(),
273 auto_start: 0,
274 restart_on_crash: 0,
275 idle_timeout_minutes: 0,
276 created_at: 1,
277 agent_type: "host".to_string(),
278 environment: String::new(),
279 agent_bus_id: String::new(),
280 is_seeded: 0,
281 accounts: String::new(),
282 parent_id: String::new(),
283 branch_label: String::new(),
284 updated_at: 1,
285 user_hidden: 0,
286 container_image: String::new(),
287 container_volumes: "[]".to_string(),
288 container_name: String::new(),
289 }
290 }
291
292 fn skill(id: &str, agent_id: &str, name: &str) -> AgentSkill {
293 AgentSkill {
294 id: id.to_string(),
295 agent_id: agent_id.to_string(),
296 name: name.to_string(),
297 trigger: String::new(),
298 skill_type: "prompt".to_string(),
299 description: String::new(),
300 content: String::new(),
301 created_at: 1,
302 }
303 }
304
305 #[test]
306 fn local_skill_delete_does_not_resurrect_from_global() {
307 let store = Store::open_in_memory().unwrap();
308 let tmp = tempfile::tempdir().unwrap();
309 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
310 store.set_def_registry(def_store.clone());
311
312 let mut def = local_def("local-1", "Local");
314 store.agent_def_insert(&mut def).unwrap();
315 store
316 .agent_skill_insert(&skill("sk-1", "local-1", "greet"))
317 .unwrap();
318 assert_eq!(
319 def_store.get("local-1").unwrap().unwrap().data.skills.len(),
320 1,
321 "skill mirrored to global"
322 );
323
324 store.agent_skill_delete("sk-1").unwrap();
326
327 assert!(
330 store.agent_skill_list("local-1").unwrap().is_empty(),
331 "deleted local skill must not reappear from the global record"
332 );
333 assert!(
336 def_store.get("local-1").unwrap().unwrap().data.skills.is_empty(),
337 "deletion must propagate cross-channel"
338 );
339 }
340
341 #[test]
342 fn local_content_delete_propagates_to_global() {
343 let store = Store::open_in_memory().unwrap();
344 let tmp = tempfile::tempdir().unwrap();
345 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
346 store.set_def_registry(def_store.clone());
347
348 let mut def = local_def("local-z", "LocalZ");
350 store.agent_def_insert(&mut def).unwrap();
351 store
352 .agent_content_set(&AgentContent {
353 agent_id: "local-z".into(),
354 content_type: "ui:zoom".into(),
355 content: "1.5".into(),
356 updated_at: 1,
357 })
358 .unwrap();
359 assert!(
360 def_store
361 .get("local-z")
362 .unwrap()
363 .unwrap()
364 .data
365 .content
366 .iter()
367 .any(|c| c.content_type == "ui:zoom"),
368 "content mirrored to global"
369 );
370
371 store.agent_content_delete("local-z", "ui:zoom").unwrap();
373
374 assert!(
376 store.agent_content_get("local-z", "ui:zoom").unwrap().is_none(),
377 "deleted local content must not reappear from the global record"
378 );
379 assert!(
383 def_store
384 .get("local-z")
385 .unwrap()
386 .unwrap()
387 .data
388 .content
389 .iter()
390 .all(|c| c.content_type != "ui:zoom"),
391 "content deletion must propagate cross-channel"
392 );
393 }
394
395 #[test]
396 fn delete_tombstones_a_cross_channel_only_agent() {
397 let store = Store::open_in_memory().unwrap();
398 let tmp = tempfile::tempdir().unwrap();
399 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
400 def_store
402 .upsert(&global_user_agent("remote-1", "Remote"))
403 .unwrap();
404 store.set_def_registry(def_store.clone());
405 assert!(store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"));
407
408 let deleted = store.agent_def_delete("remote-1").unwrap();
411 assert!(deleted, "cross-channel delete must report success");
412 assert!(!def_store.exists("remote-1"), "global record tombstoned");
413 assert!(
414 !store.agent_def_list().unwrap().iter().any(|d| d.id == "remote-1"),
415 "deleted cross-channel agent must not reappear in the roster"
416 );
417 }
418
419 #[test]
420 fn update_edits_a_cross_channel_only_agent_preserving_content_skills() {
421 let store = Store::open_in_memory().unwrap();
422 let tmp = tempfile::tempdir().unwrap();
423 let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
424 def_store
426 .upsert(&global_user_agent("remote-1", "Remote"))
427 .unwrap();
428 store.set_def_registry(def_store.clone());
429
430 let mut edited = record_to_agent_definition(&global_user_agent("remote-1", "Remote"));
432 edited.name = "Renamed".to_string();
433 let ok = store.agent_def_update(&mut edited).unwrap();
434 assert!(ok, "cross-channel update must report success, not 'not found'");
435
436 let rec = def_store.get("remote-1").unwrap().unwrap();
438 assert_eq!(rec.data.name, "Renamed");
439 assert_eq!(rec.data.content.len(), 1, "content preserved");
440 assert_eq!(rec.data.skills.len(), 1, "skills preserved");
441 assert!(store
443 .agent_def_list()
444 .unwrap()
445 .iter()
446 .any(|d| d.id == "remote-1" && d.name == "Renamed"));
447 }
448}